package com.wang.ddp.utils;

import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;

/**
 * @author wjh
 * @create 2023-04-20
 */
@Slf4j
@Component
public class CaCheClient {

    private final StringRedisTemplate stringRedisTemplate;

    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);


    public CaCheClient(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 普通的set操作
     * @param key 要存储的key
     * @param value 要存储的数据
     * @param time 设置过期时间TTL
     * @param unit 设置过期时间单位
     */
    public void set(String key, Object value, Long time, TimeUnit unit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    /**
     * 逻辑过期的存储方式，不设置TTL，只存数据，在数据中有个expireTime属性是代表过期时间
     *
     * @param key 要存储的key
     * @param value 要存储的数据
     * @param expireTime 设置逻辑过期时间
     * @param unit 设置逻辑过期时间的单位
     */
    public void setWithLogicalExpire(String key, Object value, Long expireTime, TimeUnit unit) {

        RedisData redisData = new RedisData();
        redisData.setData(value);
        //plusSeconds单位是秒，unit.toSeconds(expireTime)将时间转为秒
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(expireTime)));
        //写入redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }


    /**
     *  利用存空字符串来，解决解决缓存击穿
     *  设置随机TTL来解决大量key同时失效带来的缓存雪崩
     * @param keyPrefix key前缀
     * @param id key的id
     * @param type 需要缓存的数据类型
     * @param dbFallback    有参有返回值的函数在Java中表示Function
     * @return 返回从缓存或数据库中查到的类型
     * @param <R> 返回值和type的泛型
     * @param <ID> id泛型，通常为long
     */
    public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        //先从redis中查询数据
        String json = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isNotBlank(json)) {
            return  JSONUtil.toBean(json, type);
        }
//        isNotBlank方法只有参数是非空字符串才是true，""、"\n\t"、null，这三种都返回false
        if (json != null) {
            //如果shopJson既不是非空字符串，又不是null，那么就是空字符串""或"\n\t"空白字符串了
            //这时候代表这个数据就是空数据，数据库中都没有的数据，直接返回
            return null;
        }
//        //如果不存在,从数据库中查询
//        R r = this.getById(id);
        R r = dbFallback.apply(id);
        if (r != null) {
            //在数据库中存在，则存入redis
            //存入redis中TTL设置一个随机值，防止大量key同时失效，导致大量请求访问数据库造成巨大压力，这种情况叫缓存雪崩
//            long l = RandomUtil.randomLong(30, 40);
            //调用的是之前写过的set方法
            this.set(key, JSONUtil.toJsonStr(r), time, unit);
            return r;
        }

        //如果数据库也不存在，就出现缓存穿透，存空字符串来解决
        stringRedisTemplate.opsForValue().set(key, "", time, unit);
        return null;
    }

    /**
     * 采用逻辑删除来解决缓存击穿，逻辑删除实际上是不删除，不设置TTL，永久存在，自己来判断是否过期
     * @param keyPrefix 获取key的前缀
     * @param id        id
     * @param type      决定返回值的类型
     * @param dbFallback    函数式编程
     * @param expireTime   设置逻辑过期时间
     * @param ttl   设置互斥锁的存活时间
     * @param unit  统一设置时间单位
     * @return      泛型
     * @param <R>   返回值泛型
     * @param <ID>  参数id泛型
     */
    public <R,ID> R queryWithPassLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback,Long expireTime, Long ttl, TimeUnit unit){
        String key = keyPrefix+id;
        //先从redis中查询数据，如果没查到就直接返回null
        String json = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isBlank(json)){
            return null;
        }
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
        if (r == null){
            return null;
        }
        /**因为缓存穿透是redis不知道MySQL中有没有数据才去查询MySQL，而缓存击穿是一个被高并发访问并且缓存重建业务较复杂的key突然失效了，
         逻辑删除其实数据是一直存在的，在数据查询前会做数据预热，即将数据库中一些热点数据，查询频繁的数据添加到redis，所以
         数据库中没有的热点数据，redis中也没有，redis中没有的热点数据，数据库中也没有，此时就没必要使用存空数据来解决缓存穿透了
         所以下面的判断没必要写了**/
//        if (shopJson.equals("")){
//            return null;
//        }
        /**如果从redis中查到数据，要判断数据是否过期，如果没过期，直接返回未过期的数据，但如果过期了
         需要尝试获取互斥锁，获取锁成功的话，开辟新的线程，去查询数据库中的数据并添加到redis中更新缓存
         注意，这是新线程干的活，旧线程不会等新线程干完活再去返回新数据，逻辑删除方案就是要快，它不管你数据是否是新的它都返回
         所以无论获取锁是否成功，它都返回旧数据，直到锁里面的线程干完活，更新完数据，下一个请求发来时，才会返回新数据**/

        //判断是否过期(判断expireTime是否是在当前时间之后，如果是，就是未过期
        if (redisData.getExpireTime().isAfter(LocalDateTime.now())){
            //未过期，直接返回
            return r;
        }
        //已过期的情况

        //获取互斥锁
        String lockKey = "lock:shop:"+id;
        boolean flag = tryLock(lockKey,unit.toSeconds(ttl));
        if (!flag){
            //获取锁失败，表示有其他线程开辟新线程来更新数据，不管了，直接返回旧数据
            return r;
        }
        //如果获取锁成功，表示当前没有其他线程和我抢锁，我就可以开辟新线程去更新缓存
        CACHE_REBUILD_EXECUTOR.submit(()->{
            try {
                //缓存重建
//                this.saveShop(id,20L);
                R newR = dbFallback.apply(id);
                //这里的步骤不和之前的一样了，因为上一行的代码仅仅是调用数据库的保存操作，没有写入redis，而之前的方法将写入数据库和redis的功能
                //都封装了，这里没有只能自己写入redis,还要设置逻辑过期时间，调用的是上面写过的方法
                setWithLogicalExpire(key,newR,expireTime,unit);

            } catch (Exception e) {
                throw new RuntimeException(e);
            } finally {
                //释放锁,释放锁是新线程干的活，旧线程返回开启锁返回旧数据就完了，这里细品，如果释放锁的操作给旧线程操作会怎么样？
                unLock(lockKey);
            }
        });
        return r;
    }


    /**
     * 加锁操作
     */
    public boolean tryLock(String key,Long ttl){
        //setIfAbsent是redis中的setnx命令，如果key存在就不插入数据，只有key不存在才插入数据，用它来实现互斥锁
        //如果加锁后执行一些操作出现异常，导致锁一直存在，陷入死锁，所以为锁加个存活时间作为兜底方案，一般时间为毫秒级别
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", ttl, TimeUnit.SECONDS);
        //因为返回值是boolean,而flag是Boolean，涉及到拆箱问题，如果flag是null有可能造成空指针异常，这里使用cn.hutool.core.util的工具包解决
        return BooleanUtil.isTrue(flag);
    }

    /**
     * 释放锁操作
     * @param key 要释放锁的key
     */
    public void unLock(String key){
        stringRedisTemplate.delete(key);
    }
}
